Skip to content

Idiomatic improvements: interface cleanup, handler decomposition, element constants#39

Closed
Schmarvinius wants to merge 6 commits into
mainfrom
fix/idiom-improvements
Closed

Idiomatic improvements: interface cleanup, handler decomposition, element constants#39
Schmarvinius wants to merge 6 commits into
mainfrom
fix/idiom-improvements

Conversation

@Schmarvinius

Copy link
Copy Markdown
Contributor

Summary

A series of idiomatic improvements identified by comparing this codebase against cds-services and cds4j conventions.

Changes

1. Fix non-idiomatic patterns (#32)

  • Replace string concatenation in ServiceException messages with {} placeholders
  • Replace Collectors.toList() with .toList(), new ArrayList<>() with List.of()

2. Introduce element name constants; refactor DeploymentHandler (#33)

  • New AICoreElements class with typed constants for all entity elements
  • Replace 17-parameter buildDeploymentData method with typed DeploymentFields record
  • Replace hardcoded string literals across all handlers

3. Slim AICoreService interface to public API surface (#34)

  • Remove 6 implementation-detail methods from the interface
  • Keep only domain-level API + getRetry() (cross-module consumer)
  • Methods remain accessible on the concrete implementation classes

4. Decompose FioriRecommendationHandler (#35)

  • Extract RecommendationContextBuilder (entity analysis, query building, row assembly)
  • Extract RecommendationResultParser (type coercion, text paths, description lookup)
  • Handler reduced from ~400 lines to ~110 lines of orchestration
  • Replace unbounded ConcurrentHashMap.newKeySet() with bounded Caffeine cache

5. Accept SDK API clients as constructor parameters (#36)

  • Add overloaded constructor to AICoreServiceImpl for testability
  • Existing convenience constructor delegates with default instances

6. Document RptInferenceClient as public API (#37)

  • Add Javadoc with usage example clarifying public API intent

Test Results

All 41 unit tests pass across both modules (26 in ai-core, 15 in recommendations).

Closes

Closes #32, closes #33, closes #34, closes #35, closes #36, closes #37

- Replace string concatenation in ServiceException messages with SLF4J-style
  {} placeholders in AICoreSetupHandler (3 locations)
- Replace Collectors.toList() with .toList() in AbstractCrudHandler
- Replace new ArrayList<>() with List.of() for null case
- Remove unused imports (ArrayList, Collectors)

Closes #32
…ler with record

- Create AICoreElements class with typed element name constants for
  Deployment, ResourceGroup, Configuration, and Label entities
- Replace 17-parameter buildDeploymentData method with DeploymentFields record
- Replace two toMap() overloads with extractFields() + toCdsData() pattern
- Update all handlers to use constants instead of hardcoded strings:
  DeploymentHandler, ConfigurationHandler, ResourceGroupHandler, ActionHandler

Closes #33
Remove implementation-detail methods from AICoreService interface:
- getDefaultResourceGroup()
- getResourceGroupPrefix()
- getTenantResourceGroupCache()
- getResourceGroupDeploymentCache()
- clearTenantCache()
- resolveResourceGroupFromKeys()

These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl
but are no longer part of the service contract. Only domain-level API
(resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled)
and getRetry() (cross-module consumer) remain on the interface.

Closes #34
Extract two helper classes from the 400-line handler:

- RecommendationContextBuilder: entity analysis, prediction element
  discovery, context query building, synthetic key computation, and
  row assembly
- RecommendationResultParser: prediction value type coercion, text path
  resolution from annotations, description DB lookups, and final
  recommendations map assembly

The handler is now a slim orchestrator (~110 lines) that delegates
complex logic to the helpers.

Also replace unbounded ConcurrentHashMap.newKeySet() with a bounded
Caffeine cache (max 10,000 entries) for the entity negative-result cache.

Closes #35
Add overloaded constructor to AICoreServiceImpl that accepts
DeploymentApi, ConfigurationApi, ResourceGroupApi, and AiCoreService
as parameters. The existing convenience constructor delegates to it
with default instances.

This enables direct injection of mocked clients in unit tests without
needing to mock getter methods on the service itself.

Closes #36
Add Javadoc to RptInferenceClient clarifying that it is part of the
public API for applications that need direct RPT-1 inference outside
the automatic Fiori recommendation flow. Includes usage example.

Closes #37
@Schmarvinius Schmarvinius added the enhancement New feature or request label May 27, 2026
@hyperspace-pr-bot

Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


Idiomatic Improvements: Interface Cleanup, Handler Decomposition, and Element Constants

♻️ Refactor: A series of idiomatic improvements aligned with cds-services and cds4j conventions, improving maintainability, testability, and code clarity across both the ai-core and recommendations modules.

Changes

  • AICoreElements.java (new): Introduced typed constants for all AICore CDS entity elements (Deployment, ResourceGroup, Configuration, Label), replacing hardcoded string literals across handlers and mirroring the cds4j generated constants pattern.

  • AICoreService.java: Slimmed the interface to its public API surface — removed 6 implementation-detail methods (getDefaultResourceGroup, getResourceGroupPrefix, getTenantResourceGroupCache, getResourceGroupDeploymentCache, clearTenantCache, resolveResourceGroupFromKeys). Only domain-level API and getRetry() (used cross-module) are retained.

  • AICoreServiceImpl.java: Added an overloaded constructor that accepts SDK API clients (DeploymentApi, ConfigurationApi, ResourceGroupApi, AiCoreService) as parameters for improved unit testability. The existing convenience constructor delegates to this new one. Removed @Override annotations from methods no longer on the interface.

  • MockAICoreServiceImpl.java: Removed @Override annotations from methods no longer declared in the AICoreService interface.

  • AICoreSetupHandler.java: Replaced string concatenation in ServiceException messages with {} placeholder syntax (SLF4J-style).

  • AbstractCrudHandler.java: Replaced new ArrayList<>() with List.of() and Collectors.toList() with .toList(). Removed unused imports.

  • ActionHandler.java, ConfigurationHandler.java, DeploymentHandler.java, ResourceGroupHandler.java: Replaced all hardcoded string literals with AICoreElements constants.

  • DeploymentHandler.java: Replaced the 17-parameter buildDeploymentData method with a typed DeploymentFields record and extracted extractFields/toCdsData helpers, eliminating the CPD-OFF/CPD-ON suppression block.

  • FioriRecommendationHandler.java: Reduced from ~400 lines to ~110 lines by extracting logic into two new helper classes. Replaced the unbounded ConcurrentHashMap.newKeySet() with a bounded Caffeine cache (maximumSize(10_000)).

  • RecommendationContextBuilder.java (new): Extracted entity analysis, context query building, predict row assembly, and synthetic key computation from FioriRecommendationHandler.

  • RecommendationResultParser.java (new): Extracted type coercion, text path resolution, and description lookup logic from FioriRecommendationHandler.

  • RptInferenceClient.java: Added Javadoc with a usage example clarifying its intent as a public API class.

GitHub Issues

  • #32: Fix non-idiomatic patterns: raw EventContext, string concat in errors, pre-Java 16 stream collection
  • #33: Refactor DeploymentHandler data mapping; introduce element name constants
  • #34: Slim AICoreService interface to public API surface
  • #35: Decompose FioriRecommendationHandler into focused helper classes; bound negative cache
  • #36: Accept SDK API clients as constructor parameters for improved testability
  • #37: Audit and align class visibility modifiers in recommendation module

  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.21.0 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback


💌 Have ideas or want to contribute? Create an issue and share your thoughts with us!
📑 Check out the documentation for more information.
📬 Subscribe to the Hyperspace PR Bot DL to get the latest announcements and pilot features!

Made with ❤️ by Hyperspace.

@hyperspace-pr-bot hyperspace-pr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is a well-structured refactoring that successfully slims the interface, introduces typed constants, decomposes a large handler, and improves testability. The main concerns raised are: buildContextQuery() lacks a precondition guard making it unsafe to call in isolation; computeSyntheticKey has wider visibility than needed; the ServiceException vararg+cause overload resolution should be verified to ensure the stack trace is not silently swallowed; and the per-request RecommendationContextBuilder construction performs repeated CDS model traversals that could be expensive on hot read paths.

PR Bot Information

Version: 1.21.0 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback

  • Correlation ID: 42572f90-550a-4a5a-9f0c-03da16648fec
  • File Content Strategy: Full file content
  • LLM: anthropic--claude-4.6-sonnet
  • Event Trigger: pull_request.opened

return CdsData.create(predictRow);
}

String computeSyntheticKey(Map<String, Object> row) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: buildContextQuery() is called before checking predictionElementNames.isEmpty(), but if predictionElementNames is empty the CQL.withAnd() collector will produce an empty conjunction. More critically, this method is only safe to call when predictionElementNames is non-empty; however the guard that checks predictionElementNames().isEmpty() in the handler fires after the builder is already constructed, so the issue is in ordering, not in construction. The real problem is that computeSyntheticKey has package-private visibility but is only an internal implementation detail of assembleRows. Exposing it as package-visible (no access modifier) means test code or sibling classes can call it independently, creating unnecessary coupling.

Consider making computeSyntheticKey private since it is only called from assembleRows.

Suggested change
String computeSyntheticKey(Map<String, Object> row) {
private String computeSyntheticKey(Map<String, Object> row) {

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

return syntheticKeyNeeded;
}

CqnSelect buildContextQuery() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: buildContextQuery() calls predictionElementNames.stream().map(...).collect(CQL.withAnd()) without guarding against an empty list. If predictionElementNames is empty (which is checked by the caller after construction), this will either throw or produce a degenerate query. The check in FioriRecommendationHandler (builder.predictionElementNames().isEmpty()) correctly bails out before calling buildContextQuery(), but the method itself offers no internal protection and its contract is undocumented. At minimum, an assertion or an IllegalStateException guard should be added to make the precondition explicit and avoid silent misuse.

Suggested change
CqnSelect buildContextQuery() {
CqnSelect buildContextQuery() {
if (predictionElementNames.isEmpty()) {
throw new IllegalStateException("buildContextQuery() called with no prediction elements");
}
List<String> selectColumns = new ArrayList<>(contextColumns);

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines 42 to 46
throw new ServiceException(
ErrorStatuses.SERVER_ERROR,
"Failed to create AI Core resources for tenant: " + tenantId,
"Failed to create AI Core resources for tenant: {}",
tenantId,
e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The ServiceException constructor is being called with the signature (ErrorStatus, String messageTemplate, Object... args) where the last argument is both the tenantId vararg and the Throwable cause. Depending on how the ServiceException constructor is overloaded, the exception e may be treated as a plain message argument (i.e., e.toString() interpolated into {}), not as the cause — meaning the original stack trace would be lost in the logged/serialized error.

Verify that ServiceException has an overload that unambiguously accepts a trailing Throwable cause separately from the vararg message parameters. If not, pass the cause explicitly using the dedicated two-argument form or cast to Throwable to resolve the overload correctly.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

.or(() -> target.keyElements().map(CdsElement::getName).findFirst())
.ifPresent(col -> select.orderBy(CQL.get(col).desc()));

var builder = new RecommendationContextBuilder(target, rowType, limit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: A new RecommendationContextBuilder is instantiated on every afterRead invocation for every entity, even for entities that have already been determined to have no prediction elements (cached in entitiesWithoutPredictions). However, the cache check happens before builder construction, so that fast path is fine. The issue is that for entities that do have prediction elements, computePredictionElements() and computeContextColumns() stream over the CDS model on every single read event for every row. Since CdsStructuredType is immutable, these results could be cached per entity name alongside (or instead of) the negative entitiesWithoutPredictions cache, avoiding repeated model traversals on hot paths.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

@Schmarvinius

Copy link
Copy Markdown
Contributor Author

Restructuring into individual stacked PRs per issue.

@Schmarvinius
Schmarvinius deleted the fix/idiom-improvements branch May 27, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment